/*
 * Copyright (C) 2005-2007 Alfresco Software Limited.
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.

 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.

 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

 * As a special exception to the terms and conditions of version 2.0 of 
 * the GPL, you may redistribute this Program in connection with Free/Libre 
 * and Open Source Software ("FLOSS") applications as described in Alfresco's 
 * FLOSS exception.  You should have recieved a copy of the text describing 
 * the FLOSS exception, and it is also available here: 
 * http://www.alfresco.com/legal/licensing"
 */
package net.gqu.utils;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.UnsupportedCharsetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * This acts as a session similar to the <code>java.lang.Process</code>, but
 * logs the system standard and error streams.
 * <p>
 * The bean can be configured to execute a command directly, or be given a map
 * of commands keyed by the <i>os.name</i> Java system property.  In this map,
 * the default key that is used when no match is found is the
 * <b>{@link #KEY_OS_DEFAULT *}</b> key.
 * <p>
 * Use the {@link #setProcessDirectory(String) processDirectory} property to change the default location
 * from which the command executes.  The process's environment can be configured using the
 * {@link #setProcessProperties(Map) processProperties} property.
 * <p>
 * Commands may use placeholders, e.g.
 * <pre><code>
 *    find
 *    -name
 *    ${filename}
 * </code></pre>
 * The <b>filename</b> property will be substituted for any supplied value prior to
 * each execution of the command.  Currently, no checks are made to get or check the
 * properties contained within the command string.  It is up to the client code to
 * dynamically extract the properties required if the required properties are not
 * known up front.
 * <p>
 * Sometimes, a variable may contain several arguments.  .  In this case, the arguments
 * need to be tokenized using a standard <tt>StringTokenizer</tt>.  To force tokenization
 * of a value, use:
 * <pre><code>
 *    SPLIT:${userArgs}
 * </code></pre>
 * You should not use this just to split up arguments that are known to require tokenization
 * up front.  The <b>SPLIT:</b> directive works for the entire argument and will not do anything
 * if it is not at the beginning of the argument.  Do not use <b>SPLIT:</b> to break up arguments
 * that are fixed, so avoid doing this:
 * <pre><code>
 *    SPLIT:ls -lih
 * </code></pre>
 * Instead, break the command up explicitly:
 * <pre><code>
 *    ls
 *    -lih
 * </code></pre>
 * 
 * @author Derek Hulley
 */
public class RuntimeExec
{
    /** the key to use when specifying a command for any other OS: <b>*</b> */
    public static final String KEY_OS_DEFAULT = "*";
    
    private static final String KEY_OS_NAME = "os.name";
    private static final int BUFFER_SIZE = 1024;
    private static final String VAR_OPEN = "${";
    private static final String VAR_CLOSE = "}";
    private static final String DIRECTIVE_SPLIT = "SPLIT:";

    private static Log logger = LogFactory.getLog(RuntimeExec.class);

    private Charset charset;
    private boolean waitForCompletion;
    private Map<String, String> defaultProperties;
    private String[] processProperties;
    private File processDirectory;
    private Set<Integer> errCodes;
    
    /**
     * Default constructor.  Initialize this instance by setting individual properties.
     */
    public RuntimeExec()
    {
        this.charset = Charset.defaultCharset();
        this.waitForCompletion = true;
        defaultProperties = Collections.emptyMap();
        processProperties = null;
        processDirectory = null;
        
        // set default error codes
        this.errCodes = new HashSet<Integer>(2);
        errCodes.add(1);
        errCodes.add(2);
    }
    
    /**
     * Sets the assumed charset of OUT and ERR streams generated by the executed command.
     * This defaults to the system default charset: {@link Charset#defaultCharset()}.
     * 
     * @param charsetCode                           a supported character set code
     * @throws UnsupportedCharsetException          if the characterset code is not recognised by Java
     */
    public void setCharset(String charsetCode)
    {
        this.charset = Charset.forName(charsetCode);
    }

    /**
     * Set whether to wait for completion of the command or not.  If there is no wait for completion,
     * then the return value of <i>out</i> and <i>err</i> buffers cannot be relied upon as the
     * command may still be in progress.  Failure is therefore not possible unless the calling thread
     * waits for execution.
     * 
     * @param waitForCompletion     <tt>true</tt> (default) is to wait for the command to exit,
     *                              or <tt>false</tt> to just return an exit code of 0 and whatever
     *                              output is available at that point.
     * 
     * @since 2.1
     */
    public void setWaitForCompletion(boolean waitForCompletion)
    {
        this.waitForCompletion = waitForCompletion;
    }

    /**
     * Set the default command-line properties to use when executing the command.
     * These are properties that substitute variables defined in the command string itself.
     * Properties supplied during execution will overwrite the default properties.
     * <p>
     * <code>null</code> properties will be treated as an empty string for substitution
     * purposes.
     * 
     * @param defaultProperties property values
     */
    public void setDefaultProperties(Map<String, String> defaultProperties)
    {
        this.defaultProperties = defaultProperties;
    }

    /**
     * Set additional runtime properties (environment properties) that will used
     * by the executing process.
     * <p>
     * Any keys or properties that start and end with <b>${...}</b> will be removed on the assumption
     * that these are unset properties.  <tt>null</tt> values are translated to empty strings.
     * All keys and values are trimmed of leading and trailing whitespace.
     * 
     * @param processProperties     Runtime process properties
     * 
     * @see Runtime#exec(String, String[], java.io.File)
     */
    public void setProcessProperties(Map<String, String> processProperties)
    {
        ArrayList<String> processPropList = new ArrayList<String>(processProperties.size());
        for (Map.Entry<String, String> entry : processProperties.entrySet())
        {
            String key = entry.getKey();
            String value = entry.getValue();
            if (key == null)
            {
                continue;
            }
            if (value == null)
            {
                value = "";
            }
            key = key.trim();
            value = value.trim();
            if (key.startsWith(VAR_OPEN) && key.endsWith(VAR_CLOSE))
            {
                continue;
            }
            if (value.startsWith(VAR_OPEN) && value.endsWith(VAR_CLOSE))
            {
                continue;
            }
            processPropList.add(key + "=" + value);
        }
        this.processProperties = processPropList.toArray(new String[processPropList.size()]);
    }

    /**
     * Set the runtime location from which the command is executed.
     * <p>
     * If the value is an unsubsititued variable (<b>${...}</b>) then it is ignored.
     * If the location is not visible at the time of setting, a warning is issued only.
     * 
     * @param processDirectory          the runtime location from which to execute the command
     */
    public void setProcessDirectory(String processDirectory)
    {
        if (processDirectory.startsWith(VAR_OPEN) && processDirectory.endsWith(VAR_CLOSE))
        {
            this.processDirectory = null;
        }
        else
        {
            this.processDirectory = new File(processDirectory);
            if (!this.processDirectory.exists())
            {
                logger.warn(
                        "The runtime process directory is not visible when setting property 'processDirectory': \n" +
                        this);
            }
        }
    }

    /**
     * A comma or space separated list of values that, if returned by the executed command,
     * indicate an error value.  This defaults to <b>"1, 2"</b>.
     * 
     * @param errCodesStr the error codes for the execution
     */
    public void setErrorCodes(String errCodesStr)
    {
        errCodes.clear();
        StringTokenizer tokenizer = new StringTokenizer(errCodesStr, " ,");
        while(tokenizer.hasMoreElements())
        {
            String errCodeStr = tokenizer.nextToken();
            // attempt to convert it to an integer
            try
            {
                int errCode = Integer.parseInt(errCodeStr);
                this.errCodes.add(errCode);
            }
            catch (NumberFormatException e)
            {
                e.printStackTrace();
            }
        }
    }
    

    /**
     * Executes the statement that this instance was constructed with.
     * <p>
     * <code>null</code> properties will be treated as an empty string for substitution
     * purposes.
     * 
     * @return Returns the full execution results
     */
    public ExecutionResult execute(String[] cmd)
    {
        int defaultFailureExitValue = errCodes.size() > 0 ? ((Integer)errCodes.toArray()[0]) : 1;
        
        
        // create the properties
        Runtime runtime = Runtime.getRuntime();
        Process process = null;
        try
        {
            // execute the command with full property replacement
            process = runtime.exec(cmd, processProperties, processDirectory);
        }
        catch (IOException e)
        {
            // The process could not be executed here, so just drop out with an appropriate error state
            String execOut = "";
            String execErr = e.getMessage();
            int exitValue = defaultFailureExitValue;
            ExecutionResult result = new ExecutionResult(null, cmd, errCodes, exitValue, execOut, execErr);
            if (logger.isDebugEnabled())
            {
                logger.debug(result);
            }
            return result;
        }

        // create the stream gobblers
        InputStreamReaderThread stdOutGobbler = new InputStreamReaderThread(process.getInputStream(), charset);
        InputStreamReaderThread stdErrGobbler = new InputStreamReaderThread(process.getErrorStream(), charset);

        // start gobbling
        stdOutGobbler.start();
        stdErrGobbler.start();

        // wait for the process to finish
        int exitValue = 0;
        try
        {
            if (waitForCompletion)
            {
                exitValue = process.waitFor();
            }
        }
        catch (InterruptedException e)
        {
            // process was interrupted - generate an error message
            stdErrGobbler.addToBuffer(e.toString());
            exitValue = defaultFailureExitValue;
        }

        if (waitForCompletion)
        {
            // ensure that the stream gobblers get to finish
            stdOutGobbler.waitForCompletion();
            stdErrGobbler.waitForCompletion();
        }

        // get the stream values
        String execOut = stdOutGobbler.getBuffer();
        String execErr = stdErrGobbler.getBuffer();
        
        // construct the return value
        ExecutionResult result = new ExecutionResult(process, cmd, errCodes, exitValue, execOut, execErr);

        // done
        if (logger.isDebugEnabled())
        {
            logger.debug(result);
        }
        return result;
    }

    /**
     * Object to carry the results of an execution to the caller.
     * 
     * @author Derek Hulley
     */
    public static class ExecutionResult
    {
        private final Process process;
        private final String[] command;
        private final Set<Integer> errCodes;
        private final int exitValue;
        private final String stdOut;
        private final String stdErr;
       
        /**
         * 
         * @param process           the process attached to Java - <tt>null</tt> is allowed
         */
        private ExecutionResult(
                final Process process,
                final String[] command,
                final Set<Integer> errCodes,
                final int exitValue,
                final String stdOut,
                final String stdErr)
        {
            this.process = process;
            this.command = command;
            this.errCodes = errCodes;
            this.exitValue = exitValue;
            this.stdOut = stdOut;
            this.stdErr = stdErr;
        }
        
        @Override
        public String toString()
        {
            String out = stdOut.length() > 250 ? stdOut.substring(0, 250) : stdOut;
            String err = stdErr.length() > 250 ? stdErr.substring(0, 250) : stdErr;
            
            StringBuilder sb = new StringBuilder(128);
            sb.append("Execution result: \n")
              .append("   os:         ").append(System.getProperty(KEY_OS_NAME)).append("\n")
              .append("   command:    ").append(Arrays.deepToString(command)).append("\n")
              .append("   succeeded:  ").append(getSuccess()).append("\n")
              .append("   exit code:  ").append(exitValue).append("\n")
              .append("   out:        ").append(out).append("\n")
              .append("   err:        ").append(err);
            return sb.toString();
        }
        
        /**
         * A helper method to force a kill of the process that generated this result.  This is
         * useful in cases where the process started is not expected to exit, or doesn't exit
         * quickly.  If the {@linkplain RuntimeExec#setWaitForCompletion(boolean) "wait for completion"}
         * flag is <tt>false</tt> then the process may still be running when this result is returned.
         * 
         * @return
         *      <tt>true</tt> if the process was killed, otherwise <tt>false</tt>
         */
        public boolean killProcess()
        {
            if (process == null)
            {
                return true;
            }
            try
            {
                process.destroy();
                return true;
            }
            catch (Throwable e)
            {
                logger.warn(e.getMessage());
                return false;
            }
        }
        
        /**
         * @param exitValue the command exit value
         * @return Returns true if the code is a listed failure code
         * 
         * @see #setErrorCodes(String)
         */
        private boolean isFailureCode(int exitValue)
        {
            return errCodes.contains((Integer)exitValue);
        }
        
        /**
         * @return Returns true if the command was deemed to be successful according to the
         *      failure codes returned by the execution.
         */
        public boolean getSuccess()
        {
            return !isFailureCode(exitValue);
        }

        public int getExitValue()
        {
            return exitValue;
        }
        
        public String getStdOut()
        {
            return stdOut;
        }
    
        public String getStdErr()
        {
            return stdErr;
        }
    }

    /**
     * Gobbles an <code>InputStream</code> and writes it into a
     * <code>StringBuffer</code>
     * <p>
     * The reading of the input stream is buffered.
     */
    public static class InputStreamReaderThread extends Thread
    {
        private final InputStream is;
        private final Charset charset;
        private final StringBuffer buffer;          // we require the synchronization
        private boolean isRunning;
        private boolean completed;

        /**
         * @param is an input stream to read - it will be wrapped in a buffer
         *        for reading
         */
        public InputStreamReaderThread(InputStream is, Charset charset)
        {
            super();
            setDaemon(true); // must not hold up the VM if it is terminating
            this.is = is;
            this.charset = charset;
            this.buffer = new StringBuffer(BUFFER_SIZE);
            this.isRunning = false;
            this.completed = false;
        }

        public synchronized void run()
        {
            // mark this thread as running
            isRunning = true;
            completed = false;

            byte[] bytes = new byte[BUFFER_SIZE];
            InputStream tempIs = null;
            try
            {
                tempIs = new BufferedInputStream(is, BUFFER_SIZE);
                int count = -2;
                while (count != -1)
                {
                    // do we have something previously read?
                    if (count > 0)
                    {
                        String toWrite = new String(bytes, 0, count, charset.name());
                        buffer.append(toWrite);
                    }
                    // read the next set of bytes
                    count = tempIs.read(bytes);
                }
                // done
                isRunning = false;
                completed = true;
            }
            catch (IOException e)
            {
                e.printStackTrace();
            }
            finally
            {
                // close the input stream
                if (tempIs != null)
                {
                    try
                    {
                        tempIs.close();
                    }
                    catch (Exception e)
                    {
                    }
                }
            }
        }

        /**
         * Waits for the run to complete.
         * <p>
         * <b>Remember to <code>start</code> the thread first
         */
        public synchronized void waitForCompletion()
        {
            while (!completed && !isRunning)
            {
                try
                {
                    // release our lock and wait a bit
                    this.wait(200L); // 200 ms
                }
                catch (InterruptedException e)
                {
                }
            }
        }
        
        /**
         * @param msg the message to add to the buffer
         */
        public void addToBuffer(String msg)
        {
            buffer.append(msg);
        }

        public boolean isComplete()
        {
            return completed;
        }

        /**
         * @return Returns the current state of the buffer
         */
        public String getBuffer()
        {
            return buffer.toString();
        }
    }
}
